/*
 * dmBridge: a data access framework for CONTENTdm(R)
 *
 * Copyright © 2009, 2010, 2011 Board of Regents of the Nevada System of Higher
 * Education, on behalf of the University of Nevada, Las Vegas
 */

/**
 * The dmMonocle image viewer.
 *
 * Requires YUI 3. Inline documentation is in YUI Doc format.
 *
 * @param YUI Y YUI instance
 * @param Object dmObject Object with width, height, alias, and ptr properties
 * @param string rootUri Root URI of the viewer
 * @param string tileGeneratorUri
 *
 * @author Alex Dolski <alex.dolski@unlv.edu>
 * @license http://www.opensource.org/licenses/mit-license.php
 * @namespace edu.unlv.library.digital.dmMonocle
 * @constructor
 * @module DMMonocle
 */
function DMMonocle(Y, dmObject, rootUri, tileGeneratorUri) {

	/**
	 * @method getRect
	 * @param boolean relative If true, returns an origin relative to the
	 * parent node origin.
	 * @return DMRect
	 */
	Y.Node.prototype.getRect = function(relative) {
		var origin = new DMPoint();
		if (relative) {
			var parent = this.get("parentNode");
			origin.x = this.getX() - parent.getX();
			origin.y = this.getY() - parent.getY();
		} else {
			origin.x = this.getX();
			origin.y = this.getY();
		}
		return new DMRect(origin,
			new DMSize(
				parseInt(this.getComputedStyle("width")),
				parseInt(this.getComputedStyle("height"))));
	};

	/**
	 * @method setRect
	 * @param DMRect dmRect
	 * @param boolean relative If true, returns sets the origin relative to
	 * the parent node origin.
	 */
	Y.Node.prototype.setRect = function(dmRect, relative) {
		if (relative) {
			var parentNode = this.get("parentNode");
			this.setX(parentNode.getX() + dmRect.origin.x);
			this.setY(parentNode.getY() + dmRect.origin.y);
		} else {
			this.setXY([dmRect.origin.x, dmRect.origin.y]);
		}
		this.setStyle("width", dmRect.size.width);
		this.setStyle("height", dmRect.size.height);
	};

	/**
	 * Rotates the node's rect around its center point. Rotations must be
	 * multiples of 90 degrees.
	 *
	 * @method rotate
	 * @param int degrees
	 */
	Y.Node.prototype.rotate = function(degrees) {
		if (Math.abs(degrees) == 90 || Math.abs(degrees) == 270) {
			var newRect = this.getRect();
			var tmp = newRect.origin.x;
			newRect.origin.x = newRect.origin.y;
			newRect.origin.y = tmp;
			newRect.size.invert();
			this.setRect(newRect);
		}
	};

	/**
	 * @property HIDDEN_NAVIGATOR_Y_OFFSET
	 * @final
	 * @private
	 * @default -150
	 */
	var HIDDEN_NAVIGATOR_Y_OFFSET = -150;

	/**
	 * @property NAVIGATOR_LONGEST_SIDE
	 * @final
	 * @type int
	 * @default 120
	 * @private
	 */
	var NAVIGATOR_LONGEST_SIDE = 120;

	/**
	 * The number of zoom levels to use when the ZOOM_ALGORITHM property is
	 * set to "linear". The zoom stops will be will be (1 / n) where
	 * n <= numZoomLevels.
	 *
	 * @property NUM_ZOOM_LEVELS
	 * @final
	 * @default 6
	 * @type int
	 */
	var NUM_ZOOM_LEVELS = 6;

	/**
	 * If true, there will be a relatively large gap between the initial zoom
	 * level and the next zoom level. If false, there will be a relatively
	 * small gap.
	 *
	 * @property SKIP_FIRST_ZOOM_LEVEL
	 * @final
	 * @default true
	 * @type boolean
	 */
	var SKIP_FIRST_ZOOM_LEVEL = true;

	/**
	 * @property TILE_SIZE
	 * @default {256, 256}
	 * @final
	 * @type DMSize
	 */
	var TILE_SIZE = new DMSize(256, 256);

	/**
	 * The zooming algorithm to use. Supported algorithms are "linear"
	 * (the default) and "square". When using linear, you must also set the
	 * NUM_ZOOM_LEVELS property.
	 *
	 * @property ZOOM_ALGORITHM
	 * @default linear
	 * @final
	 * @type string
	 */
	var ZOOM_ALGORITHM = "linear";

	/**
	 * @property imageScale
	 * @type float
	 */
	this.imageScale;

	/**
	 * @property object
	 * @type DMObject
	 */
	this.object = new DMObject(dmObject.alias, dmObject.ptr,
		new DMSize(dmObject.fullSize.width, dmObject.fullSize.height));

	/**
	 * @property allTiles
	 * @private
	 * @type array
	 */
	var allTiles = [];

	/**
	 * Tracks the element being dragged.
	 *
	 * @property dragTarget
	 * @private
	 * @type Node
	 */
	var dragTarget;

	/**
	 * Cache of DOM elements to avoid repeated lookups.
	 */
	var elements = {};

	/**
	 * @property fullScreen
	 * @private
	 * @type boolean
	 */
	var fullScreen = false;

	/**
	 * Tracks the point at which the dragged element was grabbed, in screen
	 * coordinates.
	 *
	 * @property grabPoint
	 * @private
	 * @type DMPoint
	 */
	var grabPoint = new DMPoint();

	/**
	 * @property initialViewerRect
	 * @private
	 * @type DMRect
	 */
	var initialViewerRect = null;

	/**
	 * Temporary holding place for the navigator pan layer CSS border property.
	 *
	 * @property navigatorPanLayerBorder
	 * @private
	 */
	var navigatorPanLayerBorder = {};

	/**
	 * @property navigatorVisible
	 * @private
	 * @type boolean
	 */
	var navigatorVisible = true;

	/**
	 * @property rotation
	 * @private
	 * @type int
	 */
	var rotation = 0;

	/**
	 * @property thisObj
	 * @private
	 * @type DMMonocle
	 */
	var thisObj = this;

	/**
	 * Initializes the viewer.
	 *
	 * @method init
	 */
	this.init = function() {
		elements.monocle = Y.one("#dmMonocle");
		elements.monocle.append(
			'<div id="dmMonocleToolbar">'
				+ '<input id="dmMonocleShowHideNavigatorButton" class="dmMonocleButton" type="image" src="' + rootUri + '/images/image.png" title="Hide Navigator">'
				+ '<span class="dmMonocleToolbarSeparator"></span>'
				+ '<input id="dmMonocleZoomOutButton" class="dmMonocleButton" type="image" src="' + rootUri + '/images/magnifier-zoom-out.png" title="Zoom Out" disabled="disabled">'
				+ '<span id="dmMonocleZoomSlider" title="Zoom"></span>'
				+ '<input id="dmMonocleZoomInButton" class="dmMonocleButton" type="image" src="' + rootUri + '/images/magnifier-zoom-in.png" title="Zoom In">'
				+ '<input id="dmMonocleZoomLevelTextBox" type="text" size="4" maxlength="4" title="Zoom Level">'
				+ '<span class="dmMonocleToolbarSeparator"></span>'
				//+ '<input id="dmMonocleRotateCounterClockwiseButton" class="dmMonocleButton" type="image" src="' + rootUri + '/images/arrow-circle-225-left.png" title="Rotate Counterclockwise">'
				//+ '<input id="dmMonocleRotateClockwiseButton" class="dmMonocleButton" type="image" src="' + rootUri + '/images/arrow-circle-315.png" title="Rotate Clockwise">'
				//+ '<span class="dmMonocleToolbarSeparator"></span>'
				//+ '<input id="dmMonoclePrintButton" class="dmMonocleButton" type="image" src="' + rootUri + '/images/printer.png" title="Print">'
				//+ '<input id="dmMonocleFullScreenButton" class="dmMonocleButton" type="image" src="' + rootUri + '/images/arrow-out.png" title="Full Screen">'
			+ '</div>'
			+ '<div id="dmMonocleViewport">'
				+ '<div id="dmMonocleImageCanvas"></div>'
				+ '<div id="dmMonocleNavigator">'
					+ '<div id="dmMonocleNavigatorPanLayer" draggable="1"></div>'
				+ '</div>'
			+ '</div>');

		elements.imageCanvas = Y.one("#dmMonocleImageCanvas");
		elements.navigator = Y.one("#dmMonocleNavigator");
		elements.navigatorPanLayer = Y.one("#dmMonocleNavigatorPanLayer");
		elements.toolbar = Y.one("#dmMonocleToolbar");
		elements.viewport = Y.one("#dmMonocleViewport");

		// will be used by toggleFullScreen()
		initialViewerRect = elements.monocle.getRect();

		// initialize image scale
		this.imageScale = this.getInitialImageScale();

		// initialize zoom slider
		function zoomInputChanged(e) {
			if (e.keyCode == 13) {
				var data = this.getData();
				data.slider.set("value", parseInt(this.get("value")));
			}
		}
		var sliderShouldChangeScale = false;
		var allScales = thisObj.getAllImageScales();
		function onScaleSliderValueChanged(e) {
			if (!isNaN(e.newVal)) {
				for (var i = 0, count = allScales.length; i < count; i++) {
					if (parseInt(e.newVal) == parseInt(allScales[i] * 100)) {
						this.set("value", e.newVal + "%");
						if (sliderShouldChangeScale) {
							thisObj.zoomToScale(parseFloat(e.newVal) / 100);
						}
					}
				}
			}
		}
		// the slider needs this
		Y.one("body").addClass("yui3-skin-sam");

		var scaleSlider = new Y.Slider({
			value: this.imageScale * 100,
			length: 100,
			min: this.imageScale * 100,
			max: 100
		});
		var zoomTextBox = Y.one("#dmMonocleZoomLevelTextBox");
		zoomTextBox.setData({ slider: scaleSlider });
		zoomTextBox.on("keydown", zoomInputChanged);
		scaleSlider.after("valueChange", onScaleSliderValueChanged, zoomTextBox);
		scaleSlider.render("#dmMonocleZoomSlider");

		// hook up event handlers
		Y.one("window").on("resize", function(e) {
			thisObj.onWindowResize(e);
		});
		// prevents selecting stuff by double-clicking
		elements.monocle.on("selectstart", function(e) {
			e.halt();
			return false;
		});
		Y.one("input#dmMonocleShowHideNavigatorButton").on("click", function(e) {
			e.halt();
			thisObj.toggleNavigator();
		});
		Y.one("input#dmMonocleZoomInButton").on("click", function(e) {
			e.halt();
			thisObj.zoomIn();
		});
		Y.on("dmMonocle:scaleChanged", function(scale) {
			sliderShouldChangeScale = false;
			scaleSlider.set("value", parseInt(scale * 100));
			sliderShouldChangeScale = true;
			zoomTextBox.set("value", parseInt(scale * 100) + "%")
		});
		zoomTextBox.on("key", function(e) {
			e.halt();
			thisObj.zoomToScale(
				parseFloat(e.target.get("value").replace(/[^0-9.]/g, "")) / 100);
			e.target.blur();
		}, "down:13");
		Y.one("input#dmMonocleZoomOutButton").on("click", function(e) {
			e.halt();
			thisObj.zoomOut();
		});/*
		Y.one("input#dmMonocleRotateCounterClockwiseButton").on("click", function(e) {
			e.halt();
			thisObj.rotateCounterClockwise();
		});
		Y.one("input#dmMonocleRotateClockwiseButton").on("click", function(e) {
			e.halt();
			thisObj.rotateClockwise();
		});
		Y.one("input#dmMonoclePrintButton").on("click", function(e) {
			e.halt();
			thisObj.print();
		});
		Y.one("input#dmMonocleFullScreenButton").on("click", function(e) {
			e.halt();
			thisObj.toggleFullScreen();
		});*/

		var ydoc = Y.one(document);
		ydoc.on("mousedown", function(e) {
			thisObj.onMouseDown(e);
		});
		ydoc.on("mousemove", function(e) {
			thisObj.onMouseMove(e);
		});
		ydoc.on("mouseup", function(e) {
			thisObj.onMouseUp(e);
		});
		
		// initialize the navigator
		elements.navigator.setStyle("backgroundImage",
			"url(" + this.getThumbnailUri() + ")");
		var navigatorSize = this.getNavigatorSize();
		elements.navigator.setStyle("width", navigatorSize.width);
		elements.navigator.setStyle("height", navigatorSize.height);
		navigatorPanLayerBorder.width = elements.navigatorPanLayer
			.getStyle("borderWidth");
		elements.navigator.on("dblclick", function(e) {
			// navigate only if the given point doesn't overlap the pan layer
			var navRect = elements.navigator.getRect();
			var nplRect = elements.navigatorPanLayer.getRect();
			if (e.clientX < nplRect.origin.x
					|| e.clientX > nplRect.origin.x + nplRect.size.width
					|| e.clientY < nplRect.origin.y
					|| e.clientY > nplRect.origin.y + nplRect.size.height) {
				var nplOrigin = new DMPoint(
					e.clientX - nplRect.size.width / 2 - navRect.origin.x,
					e.clientY - nplRect.size.height / 2 - navRect.origin.y);
				thisObj.navigateTo(nplOrigin, true, false);
			}
		});
		this.hideNavigator(false);

		// we have liftoff
		this.zoomToScale(this.getInitialImageScale(), true);
	};

	/**
	 * @method getAllImageScales
	 * @return Array of floats representing all image scales that the viewer
	 * will display, in ascending order.
	 */
	this.getAllImageScales = function() {
		var scales = [];
		var initialScale = this.getInitialImageScale();

		switch (ZOOM_ALGORITHM) {
			case "linear":
				for (var i = 1; i <= NUM_ZOOM_LEVELS; i++) {
					scales.push(i / NUM_ZOOM_LEVELS);
				}
				break;
			case "square":
				for (var i = 1; i > 0; i /= 2) {
					if (i < initialScale) {
						break;
					}
					scales.unshift(i);
				}
				break;
		}
		if (SKIP_FIRST_ZOOM_LEVEL) {
			scales[0] = initialScale;
		} else {
			scales.unshift(initialScale);
		}
		return scales;
	};
	
	/**
	 * Centers the image horizontally in the viewport.
	 *
	 * @method centerHorizontally
	 * @see centerVertically
	 */
	this.centerHorizontally = function() {
		var canvasRect = elements.imageCanvas.getRect();
		var canvasCenter = canvasRect.getCenter();
		this.panTo(new DMPoint(canvasCenter.x, canvasRect.origin.y));
	};

	/**
	 * Centers the image vertically in the viewport.
	 *
	 * @method centerVertically
	 * @see centerHorizontally
	 */
	this.centerVertically = function() {
		var canvasRect = elements.imageCanvas.getRect();
		var canvasCenter = canvasRect.getCenter();
		this.panTo(new DMPoint(canvasRect.origin.x, canvasCenter.y));
	};

	/**
	 * @method clone
	 * @param Object obj
	 * @private
	 */
	function clone(obj) {
		if(obj == null || typeof(obj) != "object") {
			return obj;
		}
		var temp = new obj.constructor();
		for (var key in obj) {
			temp[key] = clone(obj[key]);
		}
		return temp;
	};

	/**
	 * Handles the drag event for the image canvas.
	 *
	 * @method downloadVisibleTiles
	 * @protected
	 */
	this.downloadVisibleTiles = function() {
		var viewportRect = elements.viewport.getRect();
		// check for new tiles and download if necessary
		var tiles = Y.all(".dmMonocleImageTile");
		for (var i = 0; i < tiles.size(); i++) {
			var tileNode = tiles.item(i);
			var tileRect = tileNode.getRect();
			if (tileRect.intersects(viewportRect)) {
				var tile = getTileForNode(tileNode);
				if (!tile.hasDownloaded) {
					tile.download(tileNode);
					var anim = new Y.Anim({
						node: tileNode,
						to: { opacity: 1 },
						duration: 0.4
					});
					anim.run();
				}
			}
		}
	};

	/**
	 * @method hideNavigator
	 * @param boolean animated
	 */
	this.hideNavigator = function(animated) {
		if (!navigatorVisible) {
			return;
		}
		var navigatorButton = Y.one("#dmMonocleShowHideNavigatorButton");
		var to = [
			elements.navigator.getX(),
			elements.viewport.getY() + HIDDEN_NAVIGATOR_Y_OFFSET];
		navigatorVisible = false;
		navigatorButton.set("title", "Show Navigator");

		if (animated) {
			var anim = new Y.Anim({
				node: elements.navigator,
				duration: 0.3,
				easing: Y.Easing.backIn
			});
			anim.set("to", { xy: to });
			anim.run();
		} else {
			elements.navigator.setY(to[1]);
		}
	};

	/**
	 * @method getInitialImageScale
	 * @return float The scale of the image at which its longest side fits the
	 * viewport.
	 */
	this.getInitialImageScale = function() {
		var viewportRect = elements.viewport.getRect();
		var xScale = viewportRect.size.width / this.object.size.width;
		var yScale = viewportRect.size.height / this.object.size.height;
		return (xScale < yScale) ? xScale : yScale;
	};

	/**
	 * Creates the grid of tiles for the current scale in the DOM.
	 *
	 * @method layoutTiles
	 * @private
	 */
	this.layoutTiles = function() {
		var scaledSize = new DMSize(
			this.object.size.width * this.imageScale,
			this.object.size.height * this.imageScale);
		var numRows = Math.ceil(scaledSize.height / TILE_SIZE.height);
		var numCols = Math.ceil(scaledSize.width / TILE_SIZE.width);

		// account for rotation
		if (Math.abs(rotation) > 0) {
			elements.imageCanvas.rotate(Math.abs(rotation));
			if (Math.abs(rotation) == 90 || Math.abs(rotation) == 270) {
				var tmp = numRows;
				numRows = numCols;
				numCols = tmp;
			}
		}

		// remove old image tiles
		allTiles = [];
		Y.all(".dmMonocleImageTile").each(function() {
			this.remove();
		});

		// append image tiles as children of the pan layer
		for (var i = 0; i < numRows; i++) {
			for (var j = 0; j < numCols; j++) {
				var sw = dw = TILE_SIZE.width;
				var sh = dh = TILE_SIZE.height;
				var dx = elements.imageCanvas.getX() + j * TILE_SIZE.width;
				var dy = elements.imageCanvas.getY() + i * TILE_SIZE.height;
				switch (Math.abs(rotation)) {
					case 90:
						var sx = i * TILE_SIZE.width;
						var sy = (numRows - 1) * TILE_SIZE.height - j * TILE_SIZE.height;
						break;
					case 180:
						var sx = (numCols - 1) * TILE_SIZE.width - j * TILE_SIZE.width;
						var sy = (numRows - 1) * TILE_SIZE.height - i * TILE_SIZE.height;
						if (j == numCols - 1) {
							sw -= scaledSize.width - (numCols - 1) * TILE_SIZE.width;
						}
						if (i == numRows - 1) {
							sh -= scaledSize.height - (numRows - 1) * TILE_SIZE.height;
						}
						break;
					case 270:
						var sx = (numCols - 1) * TILE_SIZE.width - i * TILE_SIZE.width;
						var sy = j * TILE_SIZE.height;
						break;
					default:
						var sx = j * TILE_SIZE.width;
						var sy = i * TILE_SIZE.height;
						if (j == numCols - 1) {
							dw = scaledSize.width - TILE_SIZE.width * (numCols - 1);
						}
						if (i == numRows - 1) {
							dh = scaledSize.height - TILE_SIZE.height * (numRows - 1);
						}
						break;
				}

				var sourceRect = new DMRect(
					new DMPoint(sx, sy), new DMSize(sw, sh));
				var destRect = new DMRect(
					new DMPoint(dx, dy), new DMSize(dw, dh));

				var tile = new DMTile(this.object, sourceRect, destRect,
					rotation, this.imageScale, tileGeneratorUri);

				allTiles.push(tile);
				elements.imageCanvas.appendChild(tile.getNode(elements.imageCanvas));
			}
		}
	};

	/**
	 * @method navigateTo
	 * @param DMPoint origin X/Y coordinate on the navigator.
	 * @param boolean animated
	 * overlaps the current position of the navigator pan layer.
	 * @see panTo
	 */
	this.navigateTo = function(origin, animated) {
		var navSize = elements.navigator.getRect().size;
		var canvasOrigin = new DMPoint(
			(origin.x / navSize.width) * this.object.size.width * this.imageScale,
			(origin.y / navSize.height) * this.object.size.height * this.imageScale);
		this.panTo(canvasOrigin, animated);
	};

	/**
	 * @method getNavigatorSize
	 * @return DMSize
	 */
	this.getNavigatorSize = function() {
		var size = clone(this.object.size);
		if (Math.abs(rotation) == 90 || Math.abs(rotation) == 270) {
			size.invert();
		}
		return (size.width > size.height)
			? new DMSize(NAVIGATOR_LONGEST_SIDE,
				(size.height / size.width) * NAVIGATOR_LONGEST_SIDE)
			: new DMSize((size.width / size.height) * NAVIGATOR_LONGEST_SIDE,
				NAVIGATOR_LONGEST_SIDE);
	};

	/**
	 * @method getNextHigherImageScale
	 * @return float
	 */
	this.getNextHigherImageScale = function() {
		var allScales = this.getAllImageScales();
		for (var i = 0; i < allScales.length; i++) {
			if (allScales[i] > this.imageScale) {
				return allScales[i];
			}
		}
		return null;
	};

	/**
	 * @method getNextLowerImageScale
	 * @return float
	 */
	this.getNextLowerImageScale = function() {
		var allScales = this.getAllImageScales();
		for (var i = allScales.length - 1; i >= 0; i--) {
			if (allScales[i] < this.imageScale) {
				return allScales[i];
			}
		}
		return null;
	};

	/**
	 * @method onMouseDown
	 * @param Event evt
	 * @private
	 */
	this.onMouseDown = function(evt) {
		var evtTarget = Y.Node.getDOMNode(evt.target);
		if (evtTarget.getAttribute("draggable")) {
			evt.preventDefault();
			dragTarget = evtTarget;
			if (dragTarget.id == "dmMonocleNavigatorPanLayer") {
				grabPoint.x = evt.clientX - evt.target.getX();
				grabPoint.y = evt.clientY - evt.target.getY();
			} else {
				grabPoint.x = evt.clientX - evt.target.get("parentNode").getX();
				grabPoint.y = evt.clientY - evt.target.get("parentNode").getY();
			}
		}
	};

	/**
	 * @method onMouseMove
	 * @param Event evt
	 * @private
	 */
	this.onMouseMove = function(evt) {
		// panning is not possible at min scale
		if (dragTarget && this.getNextLowerImageScale()) {
			evt.preventDefault();
			var newOrigin = new DMPoint(
				evt.clientX - grabPoint.x,
				evt.clientY - grabPoint.y);
			if (dragTarget.id == "dmMonocleNavigatorPanLayer") {
				newOrigin.x -= elements.navigator.getX();
				newOrigin.y -= elements.navigator.getY();
				this.navigateTo(newOrigin, false);
			} else {
				newOrigin.x = -newOrigin.x + elements.viewport.getX();
				newOrigin.y = -newOrigin.y + elements.viewport.getY();
				this.panTo(newOrigin, false);
			}
		}
	};

	/**
	 * @method onMouseUp
	 * @param Event evt
	 * @private
	 */
	this.onMouseUp = function(evt) {
		dragTarget = null;
	};

	/**
	 * Handles the window-resize event.
	 *
	 * @method onWindowResize
	 * @param Event e
	 * @private
	 */
	this.onWindowResize = function(e) {
		var vpRect = elements.viewport.getRect();
		var scaledSize = new DMSize(
			this.object.size.width * this.imageScale,
			this.object.size.height * this.imageScale);
		if (scaledSize.width < vpRect.size.width
				&& scaledSize.height < vpRect.size.height) {
			this.zoomToScale(this.getAllImageScales()[0]);
		} else {
			if (scaledSize.width < vpRect.size.width) {
				this.centerHorizontally();
			} else if (scaledSize.height < vpRect.size.height) {
				this.centerVertically();
			}
		}

		// if in full screen mode, keep the viewer at full screen
		if (fullScreen) {
			var newRect = new DMRect();
			newRect.size.width = parseInt(elements.monocle.get("winWidth"));
			newRect.size.height = parseInt(elements.monocle.get("winHeight"));
			elements.monocle.setRect(newRect);
			elements.viewport.setStyle("height",
				parseInt(elements.monocle.getStyle("height")) - elements.viewport.getY());
		}
	};

	/**
	 * @method panTo
	 * @param DMPoint origin X/Y coordinate on the image canvas.
	 * @param boolean animated
	 * @see navigateTo
	 */
	this.panTo = function(origin, animated) {
		origin.x = -origin.x;
		origin.y = -origin.y;
		
		var npl = elements.navigatorPanLayer;
		var canvas = elements.imageCanvas;
		var nplRect = elements.navigatorPanLayer.getRect(true);
		var canvasRect = canvas.getRect();
		var vpRect = elements.viewport.getRect();
		var navRect = elements.navigator.getRect();
		var toolbarRect = elements.toolbar.getRect();

		var scaledSize = new DMSize(
			this.object.size.width * this.imageScale,
			this.object.size.height * this.imageScale);
		var newCanvasOrigin = origin;

		// if the viewport is wider than the scaled canvas, center the canvas
		// horizontally
		if (vpRect.size.width >= scaledSize.width) {
			newCanvasOrigin.x = (vpRect.size.width - scaledSize.width) / 2;
		} else {
			// constrain left
			newCanvasOrigin.x = (newCanvasOrigin.x > 0) ? 0 : newCanvasOrigin.x;
			// constrain right
			if (newCanvasOrigin.x + scaledSize.width < vpRect.size.width) {
				newCanvasOrigin.x = vpRect.size.width - scaledSize.width;
			}
		}
		// if the viewport is taller than the scaled canvas, center the canvas
		// vertically
		if (vpRect.size.height >= scaledSize.height) {
			newCanvasOrigin.y = (vpRect.size.height - scaledSize.height) / 2;
		} else {
			// constrain top
			newCanvasOrigin.y = (newCanvasOrigin.y > 0) ? 0 : newCanvasOrigin.y;
			// constrain bottom
			if (newCanvasOrigin.y + scaledSize.height
					< vpRect.size.height - toolbarRect.size.height) {
				newCanvasOrigin.y = vpRect.size.height
					- scaledSize.height - toolbarRect.size.height;
			}
		}

		// convert the image canvas coordinates to navigator coordinates
		var navSize = this.getNavigatorSize();
		var newNPLOrigin = new DMPoint(
			(-newCanvasOrigin.x / scaledSize.width) * navSize.width,
			(-newCanvasOrigin.y / scaledSize.height) * navSize.height);
		// if the image x axis doesn't fill the viewport...
		if (scaledSize.width < vpRect.size.width) {
			newNPLOrigin.x = 0;
		// same for y
		} else if (scaledSize.height < vpRect.size.height) {
			newNPLOrigin.y = 0;
		}

		// translate the coordinates to their element origins
		newCanvasOrigin.x += vpRect.origin.x;
		newCanvasOrigin.y += vpRect.origin.y;
		newNPLOrigin.x += navRect.origin.x;
		newNPLOrigin.y += navRect.origin.y;

		if (animated) {
			var animationDuration = 0.25;
			var nplAnim = new Y.Anim({
				node: npl,
				to: { xy: [newNPLOrigin.x, newNPLOrigin.y] },
				duration: animationDuration,
				easing: Y.Easing.easeOut
			});
			var canvasAnim = new Y.Anim({
				node: elements.imageCanvas,
				to: { xy: [newCanvasOrigin.x, newCanvasOrigin.y] },
				duration: animationDuration,
				easing: Y.Easing.easeOut
			});
			nplAnim.run();
			canvasAnim.run();
			// animation is non-blocking, so wait for it to complete
			Y.later(animationDuration * 1000, this, "downloadVisibleTiles");
		} else {
			npl.setXY([newNPLOrigin.x, newNPLOrigin.y]);
			canvas.setXY([newCanvasOrigin.x, newCanvasOrigin.y]);
			this.downloadVisibleTiles();
		}
	};

	/**
	 * Prints the viewer. Appearance is controlled by the stylesheet.
	 *
	 * @method print
	 */
	this.print = function() {
		window.print();
	};

	/**
	 * @method rotateClockwise
	 */
	this.rotateClockwise = function() {
		rotation = (rotation - 90 <= -360) ? 0 : rotation - 90;
		this.layoutTiles();
	};

	/**
	 * @method rotateCounterClockwise
	 */
	this.rotateCounterClockwise = function() {
		rotation = (rotation + 90 >= 360) ? 0 : rotation + 90;
		this.layoutTiles();
	};

	/**
	 * @method showNavigator
	 * @param boolean animated
	 */
	this.showNavigator = function(animated) {
		if (navigatorVisible) {
			return;
		}
		var navigatorButton = Y.one("#dmMonocleShowHideNavigatorButton");
		var toolbar = Y.one("#dmMonocleToolbar");

		var to = [elements.navigator.getX(), elements.viewport.getY()];
		navigatorVisible = true;
		navigatorButton.set("title", "Hide Navigator");

		if (animated) {
			var anim = new Y.Anim({
				node: elements.navigator,
				duration: 0.3,
				easing: Y.Easing.backIn
			});
			anim.set("to", { xy: to });
			anim.run();
		} else {
			elements.navigator.setY(to[1]);
		}
	};

	/**
	 * @method getThumbnailUri
	 * @return URI string
	 */
	this.getThumbnailUri = function() {
		var objectSize = clone(this.object.size);
		var sourceSize = clone(this.object.size);
		if (Math.abs(rotation) == 90 || Math.abs(rotation) == 270) {
			objectSize.invert();
			sourceSize.invert();
		}
		sourceSize.scaleTo(this.getNavigatorSize());
		var sourceRect = destRect = new DMRect(new DMPoint(0, 0), sourceSize);
		var tileScale = (sourceSize.width < sourceSize.height)
			? sourceSize.width / objectSize.width
			: sourceSize.height / objectSize.height;

		if (Math.abs(rotation) == 90 || Math.abs(rotation) == 270) {
			sourceSize.invert();
		}
		var tile = new DMTile(this.object, sourceRect, destRect, rotation,
			tileScale, tileGeneratorUri);
		return tile.getUri();
	};

	/**
	 * @method getTileForNode
	 * @param Node node
	 * @private
	 */
	function getTileForNode(node) {
		for (var i = 0; i < allTiles.length; i++) {
			if (allTiles[i].getNode() == node) {
				return allTiles[i];
			}
		}
		return null;
	};

	/**
	 * Toggles full screen mode.
	 *
	 * @method toggleFullScreen
	 */
	this.toggleFullScreen = function() {
		var viewerRect = elements.monocle.getRect();
		var newMonocleRect = new DMRect();

		if (fullScreen) {
			newMonocleRect.origin.x = initialViewerRect.origin.x;
			newMonocleRect.origin.y = initialViewerRect.origin.y;
			newMonocleRect.size.width = initialViewerRect.size.width;
			newMonocleRect.size.height = initialViewerRect.size.height;
			fullScreen = false;
		} else {
			newMonocleRect.origin.x = 0;
			newMonocleRect.origin.y = 0;
			newMonocleRect.size.width = parseInt(elements.monocle.get("winWidth"));
			newMonocleRect.size.height = parseInt(elements.monocle.get("winHeight"));
			fullScreen = true;
		}
		elements.monocle.setRect(newMonocleRect);

		var visibleRect = this.getVisibleImageRect();
		var visibleCenter = visibleRect.getCenter();

		if (fullScreen) {
			var allScales = this.getAllImageScales();
			if (this.imageScale < allScales[0]) {
				this.zoomToScale(allScales[0]);
			}
		}

		// refresh the pan layer size
		var navRect = elements.navigator.getRect();
		var nplRect = elements.navigatorPanLayer.getRect(true);
		var viewportRect = elements.viewport.getRect();
		nplRect.size.width = (this.getNextLowerImageScale())
			? viewportRect.size.width / (this.object.size.width * this.imageScale)
				* navRect.size.width
			: navRect.size.width;
		nplRect.size.height = (this.getNextLowerImageScale())
			? viewportRect.size.height / (this.object.size.height * this.imageScale)
				* navRect.size.height
			: navRect.size.height;
		elements.navigatorPanLayer.setRect(nplRect, true);

		// pan to the center of the previous viewport
		var newVpRect = elements.viewport.getRect();
		var newCanvasOrigin = new DMPoint(
			visibleCenter.x - (newVpRect.size.width / this.imageScale) / 2,
			visibleCenter.y - (newVpRect.size.height / this.imageScale) / 2);
		this.panTo(newCanvasOrigin);
	};

	/**
	 * Toggles the corner navigator window visible or not.
	 *
	 * @method toggleNavigator
	 * @param boolean animated Default is true
	 */
	this.toggleNavigator = function(animated) {
		if (typeof animated == "undefined") {
			animated = true;
		}
		if (navigatorVisible) {
			this.hideNavigator(animated);
		} else {
			this.showNavigator(animated);
		}
	};

	/**
	 * Returns a DMRect representing the visible area of the image canvas, in
	 * image canvas coordinates.
	 *
	 * @method getVisibleImageRect
	 * @return DMRect
	 */
	this.getVisibleImageRect = function() {
		var viewportRect = elements.viewport.getRect();
		var canvasRect = elements.imageCanvas.getRect();
		return new DMRect(
			new DMPoint(
				Math.abs(canvasRect.origin.x),
				Math.abs(canvasRect.origin.y)),
			new DMSize(
				(viewportRect.size.width / (this.object.size.width * this.imageScale))
					* this.object.size.width,
				(viewportRect.size.height / (this.object.size.height * this.imageScale))
					* this.object.size.height));
	};

	/**
	 * @method zoomIn
	 */
	this.zoomIn = function() {
		var nextScale = this.getNextHigherImageScale();
		if (!nextScale) {
			throw "DMMonocleZoomException";
		}
		this.zoomToScale(nextScale);
	};

	/**
	 * @method zoomOut
	 * @throws DMMonocleZoomException
	 */
	this.zoomOut = function() {
		var nextScale = this.getNextLowerImageScale();
		if (!nextScale) {
			throw "DMMonocleZoomException";
		}
		this.zoomToScale(nextScale);
	};

	/**
	 * @method zoomToScale
	 * @param float scale
	 * @param boolean force
	 * @event dmMonocle:scaleChanged
	 */
	this.zoomToScale = function(scale, force) {
		if (scale == this.imageScale && !force) {
			return;
		}

		var allScales = this.getAllImageScales();
		if (scale < allScales[0]) {
			scale = allScales[0];
		} else if (scale > allScales[allScales.length - 1]) {
			scale = allScales[allScales.length - 1];
		}

		this.imageScale = scale;

		var canvas = elements.imageCanvas;
		var navRect = elements.navigator.getRect();
		var npl = elements.navigatorPanLayer;
		var nplRect = npl.getRect(true);
		var viewportRect = elements.viewport.getRect();
		var scaledImageSize = new DMSize(
			this.object.size.width * this.imageScale,
			this.object.size.height * this.imageScale);

		// refresh the canvas size
		var newCanvasRect = canvas.getRect();
		newCanvasRect.size = scaledImageSize;
		canvas.setRect(newCanvasRect);

		this.layoutTiles();

		var newNplRect = clone(nplRect);
		var nplCenter = nplRect.getCenter();

		// refresh the pan layer origin & size
		// @todo consolidate with the same code in toggleFullScreen()
		newNplRect.size.width = (this.getNextLowerImageScale())
			? viewportRect.size.width / (this.object.size.width * this.imageScale)
				* navRect.size.width
			: navRect.size.width;
		newNplRect.size.height = (this.getNextLowerImageScale())
			? viewportRect.size.height / (this.object.size.height * this.imageScale)
				* navRect.size.height
			: navRect.size.height;
		newNplRect.setCenter(nplCenter);
		this.navigateTo(newNplRect.origin, false);

		// constrain left edge
		if (newNplRect.origin.x < 0) {
			newNplRect.origin.x = 0;
		}
		// constrain right edge
		if (newNplRect.origin.x + newNplRect.size.width > navRect.size.width) {
			newNplRect.origin.x = navRect.size.width - newNplRect.size.width;
		}
		// constrain top edge
		if (newNplRect.origin.y < 0) {
			newNplRect.origin.y = 0;
		}
		// constrain bottom edge
		if (newNplRect.origin.y + newNplRect.size.height > navRect.size.height) {
			newNplRect.origin.y = navRect.size.height - newNplRect.size.height;
		}

		elements.navigatorPanLayer.setRect(newNplRect, true);

		// refresh the zoom level text box
		Y.one("#dmMonocleZoomLevelTextBox").set(
			"value", parseInt(this.imageScale * 100) + "%");

		var zoomInButton = Y.one("#dmMonocleZoomInButton");
		var zoomOutButton = Y.one("#dmMonocleZoomOutButton");
		var navButton = Y.one("#dmMonocleShowHideNavigatorButton");

		// if we are at maximum zoom level, disable zooming in
		if (this.imageScale >= 1) {
			zoomInButton.set("disabled", true);
			zoomInButton.setStyle("opacity", 0.3);
		} else {
			zoomInButton.set("disabled", false);
			zoomInButton.setStyle("opacity", 1);
		}
		// if we are at minimum zoom level, disable zooming out and hide the NPL
		if (this.imageScale <= this.getAllImageScales()[0]) {
			canvas.setStyle("cursor", "auto");
			this.hideNavigator(true);
			navButton.set("disabled", true);
			navButton.setStyle("opacity", 0.3);
			zoomOutButton.set("disabled", true);
			zoomOutButton.setStyle("opacity", 0.3);
			npl.setStyle("borderWidth", 0);
		} else {
			canvas.setStyle("cursor", "move");
			this.showNavigator(true);
			navButton.set("disabled", false);
			navButton.setStyle("opacity", 1);
			zoomOutButton.set("disabled", false);
			zoomOutButton.setStyle("opacity", 1);
			npl.setStyle("borderWidth", navigatorPanLayerBorder.width);
		}
		
		Y.fire("dmMonocle:scaleChanged", this.imageScale);
	};

	/**
	 * @param string alias
	 * @param int ptr
	 * @param DMSize size
	 * @for DMMonocle
	 * @constructor
	 */
	function DMObject(alias, ptr, dmSize) {
		this.alias = alias;
		this.ptr = ptr;
		this.size = dmSize;
	};

	/**
	 * @param int x
	 * @param int y
	 * @for DMMonocle
	 * @constructor
	 */
	function DMPoint(x, y) {
		this.x = parseInt(x);
		this.y = parseInt(y);
	};

	/**
	 * @param int width
	 * @param int height
	 * @for DMMonocle
	 * @constructor
	 */
	function DMSize(width, height) {
		this.width = parseInt(width);
		this.height = parseInt(height);

		/**
		 * @method invert
		 * @return DMSize The DMSize instance
		 */
		this.invert = function() {
			var tmp = this.width;
			this.width = this.height;
			this.height = tmp;
			return this;
		};

		/**
		 * Scales the instance to fit inside the given size, preserving its
		 * aspect ratio.
		 *
		 * @method scaleTo
		 * @param DMSize dmSize
		 */
		this.scaleTo = function(dmSize) {
			var xScale = dmSize.width / this.width;
			var yScale = dmSize.height / this.height;
			var scale = (xScale < yScale) ? xScale : yScale;
			this.width *= scale;
			this.height *= scale;
		};
	};

	/**
	 * @param DMPoint dmPoint
	 * @param DMSize dmSize
	 * @for DMMonocle
	 * @constructor
	 */
	function DMRect(dmPoint, dmSize) {

		/**
		 * @property origin
		 * @type DMPoint
		 */
		this.origin = dmPoint ? dmPoint : new DMPoint();

		/**
		 * The rotation of the rect, in degrees. This affects the return value
		 * of getDisplaySize() but does not affect the raw origin or size
		 * properties.
		 *
		 * @property rotation
		 * @default 0
		 * @type int
		 */
		this.rotation = 0;

		/**
		 * @property size
		 * @type DMSize
		 */
		this.size = dmSize ? dmSize : new DMSize();

		/**
		 * @method getCenter
		 * @return DMPoint
		 */
		this.getCenter = function() {
			return new DMPoint(
				this.origin.x + (this.size.width / 2),
				this.origin.y + (this.size.height / 2));
		};

		/**
		 * @method setCenter
		 * @param DMPoint dmPoint
		 */
		this.setCenter = function(dmPoint) {
			this.origin = new DMPoint(
				dmPoint.x - (this.size.width / 2),
				dmPoint.y - (this.size.height / 2));
		};

		/**
		 * @method getDisplaySize
		 * @return DMSize
		 */
		this.getDisplaySize = function() {
			var displaySize = clone(this.size);
			if (Math.abs(this.rotation == 90) || Math.abs(this.rotation == 270)) {
				displaySize.invert();
			}
			return displaySize;
		};

		/**
		 * @method intersects
		 * @param DMRect dmRect
		 * @return boolean
		 */
		this.intersects = function(dmRect) {
			var thisTop = this.origin.y;
			var thisBottom = this.origin.y + this.size.height;
			var thisLeft = this.origin.x;
			var thisRight = this.origin.x + this.size.width;

			var thatTop = dmRect.origin.y;
			var thatBottom = dmRect.origin.y + dmRect.size.height;
			var thatLeft = dmRect.origin.x;
			var thatRight = dmRect.origin.x + dmRect.size.width;

			return (thisLeft <= thatRight && thatLeft <= thisRight
				&& thisTop <= thatBottom && thatTop <= thisBottom);
		};

	};

	/**
	 * @param DMObject dmObject
	 * @param DMRect sourceRect Rect in source image coordinates.
	 * @param DMRect destRect Rect in screen coordinates.
	 * @param int rotation
	 * @param float scale
	 * @param string generatorUri
	 * @for DMMonocle
	 * @constructor
	 */
	function DMTile(dmObject, sourceRect, destRect, rotation, scale,
			generatorUri) {
		this.destRect = destRect;
		this.hasDownloaded = false;
		this.object = dmObject;
		this.sourceRect = sourceRect;
		this.rotation = rotation;
		this.scale = scale;

		var node = null;

		/**
		 * Downloads the tile by setting its source (src). If the
		 * tile has already downloaded, does nothing.
		 *
		 * @method download
		 * @param YUInode node
		 */
		this.download = function(node) {
			if (!this.hasDownloaded) {
				node.set("src", this.getUri());
				this.hasDownloaded = true;
			}
		};

		/**
		 * @method getNode
		 * @param Node parentNode Optional; if the tile does not already exist,
		 * it will be created as a child of this node.
		 * @return Node img element
		 */
		this.getNode = function(parentNode) {
			if (!node && parentNode) {
				var newNode = parentNode.appendChild(
					'<img class="dmMonocleImageTile">');
				newNode.setX(this.destRect.origin.x);
				newNode.setY(this.destRect.origin.y);
				newNode.set("width", this.destRect.size.width);
				newNode.set("height", this.destRect.size.height);
				newNode.setAttribute("draggable", 1);
				node = newNode;
			}
			return node;
		};

		/**
		 * @method getUri
		 * @return string
		 */
		this.getUri = function() {
			return generatorUri
				+ "?CISOROOT=" + this.object.alias
				+ "&CISOPTR=" + this.object.ptr
				+ "&DMX=" + this.sourceRect.origin.x
				+ "&DMY=" + this.sourceRect.origin.y
				+ "&DMWIDTH=" + this.sourceRect.size.width
				+ "&DMHEIGHT=" + this.sourceRect.size.height
				+ "&DMROTATE=" + this.rotation
				+ "&DMSCALE=" + this.scale * 100;
		};

	};

}
